今日讀物:《Clean Craftsmanship 無暇的程式碼 軟體工匠篇》(Chapter 3 進階 TDD)、《Test-Driven Development 學習手冊》(迅速翻完,沒有實作)
2000 年 Gerard Meszaros 的著作《xUnit Test Patterns: Refractoring Test Code》定義了非正式模擬物件 (Mock Object) 的五種類型,統稱這些物件為「測試替身 Test Doubles」:執行測試時,測試替身代替了另一個物件
Dummy 虛擬物件
Stub 虛擬常式或擬態物件
Spy 情蒐或間諜物件
Mock 模擬物件
Fake 假物件
Test Double
|
+-----------------------+------------------------+
| |
行為鏈:能力遞增 簡化真實實作
| |
只佔位 ──► Dummy |
(不驗證,不被使用) |
| |
給假資料 ──► Stub |
(控制間接輸入,做 state verification) |
| |
記錄互動 ──► Spy |
(記錄間接輸出,事後 assert 行為) |
| |
檢查互動是否符合預期 ──► Mock Fake Object
(事先定義期望,行為驗證 behavior verification) (有邏輯的簡化實作)被測函式需要輸入物件但不需要對物件做任何邏輯處理的時候,Dummy 此時實作介面 Implement interface 但什麼都不做,以避免在測試程式中宣告一個複雜的物件,通常不會真的被 SUT (System Under Test) 使用到,就是在輸入參數的地方佔位;若要回傳通常會是 null 或 0,多數情況不會被讀取
例:登入驗證函式必須在接收使用者名稱和密碼輸入值後才啟動,Dummy 就可以直接設定成一組空的使用者名稱和密碼 Uncle Bob 個人不是很喜歡,因為會有路徑引用和依賴鏈問題
// auth-service.js
export const login = (username, password, logger) => {
// logger 會被呼叫,但這個測試用不到
if (!username || !password) {
logger.error('missing credentials');
return false;
}
return true;
};
// auth-service.test.js
import { describe, it, expect } from 'vitest';
import { login } from './auth-service.js';
describe('login', () => {
it('returns false when username is empty', () => {
const dummyLogger = {
// 實作介面,但什麼都不做
error: () => {},
}; // Dummy
const result = login('', 'password', dummyLogger);
expect(result).toBe(false);
});
});dummyLogger 實作了需要的介面 (error),但測試完全不會對它做任何驗證,就是純佔位。
Stub 是一種 Dummy,但可以回傳一些驅動被測函式繼續執行邏輯流程的值 test-specific value (或稱測試專用值),用 SUT 的狀態或回傳值驗證 回傳的是「預先寫死」的值,不太關心呼叫了幾次、順序如何
例:回傳 true/ false,確認登入驗證函式接收合法/非法使用者名稱和密碼輸入值後是否按照設計流程執行
// user-repo.js
export class UserRepository {
findByUsername(username) {
throw new Error('not implemented');
}
}
// auth-service.js
export const createAuthService = (userRepository) => ({
async login(username, password) {
const user = await userRepository.findByUsername(username);
if (!user) return false;
return user.password === password;
},
});
// auth-service.stub.test.js
import { describe, it, expect } from 'vitest';
import { createAuthService } from './auth-service.js';
describe('AuthService with Stub', () => {
it('returns true when username and password match', async () => {
const userRepoStub = {
// 只回傳測試專用值
async findByUsername(username) {
if (username === 'alice') {
return { username: 'alice', password: 'secret' };
}
return null;
},
}; // Stub
const auth = createAuthService(userRepoStub);
const result = await auth.login('alice', 'secret');
expect(result).toBe(true);
});
});userRepoStub 不記錄誰呼叫它、也沒有期望被呼叫幾次,只單純「給假資料」,這就是 Stub。
Spy 是一種 Stub,回傳測試專用值使被測函式通過期望路徑,同時會「紀錄被怎麼呼叫」(包含呼叫的歷史、呼叫時使用的參數),之後由測試來檢查紀錄與斷言
// email-service.js
export const notifyLogin = async (emailClient, user) => {
await emailClient.send({
to: user.email,
subject: 'Login detected',
});
};
// email-service.spy.test.js
import { describe, it, expect } from 'vitest';
import { notifyLogin } from './email-service.js';
describe('notifyLogin with Spy', () => {
it('sends an email to user email', async () => {
const calls = [];
const emailClientSpy = {
async send(message) {
calls.push(message);
// 也可以在這裡回傳測試專用值
return { ok: true };
},
}; // Spy:同時是 Stub + 記錄呼叫
const user = { email: 'user@example.com' };
await notifyLogin(emailClientSpy, user);
// 驗證「怎麼被呼叫」在測試裡寫
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({
to: 'user@example.com',
subject: 'Login detected',
});
});
});emailClientSpy 自己不會「判斷測試成敗」,只是把資訊記下來,由測試 expect 來驗證,這就是 Spy 的典型用法。
如果使用 Vitest 的 vi.fn() ,不對它做任何期望檢查,只讀 mock.calls 來 assert,其角色偏向 Spy;一旦搭配 toHaveBeenCalledWith 等 API,就是 Mock。
Mock 是一種 Spy,回傳測試專用值使被測函式通過期望路徑,同時會記錄被怎麽呼叫,根據事先設定的預期結果判斷測試成功或失敗,屬於行為驗證(behavior verification)
Spy 和 Mock 都用來驗證間接輸出,只是一個是「把記錄交給測試自行斷言」,一個是「把期望預先設定好,由框架檢查」。
Uncle Bob 個人不太喜歡,因為 Mock 讓 Spy 的行為和測試驗證整個流程緊密耦合,而不是他個人偏好的直接陳述驗證內容
// payment-service.js
export const chargeOrder = async (paymentGateway, order) => {
const amount = order.items.reduce((sum, item) => sum + item.price, 0);
return paymentGateway.charge(order.userId, amount);
};
// payment-service.mock.test.js
import { describe, it, expect, vi } from 'vitest';
import { chargeOrder } from './payment-service.js';
describe('chargeOrder with Mock', () => {
it('charges total amount to user', async () => {
const paymentGatewayMock = {
charge: vi.fn().mockResolvedValue({ success: true }),
}; // Mock function:有預期回傳、也會被驗證行為
const order = {
userId: 'u1',
items: [
{ price: 10 },
{ price: 20 },
],
};
const result = await chargeOrder(paymentGatewayMock, order);
expect(result).toEqual({ success: true });
// 行為驗證:這裡才是「Mock 的重點」
expect(paymentGatewayMock.charge).toHaveBeenCalledTimes(1);
expect(paymentGatewayMock.charge).toHaveBeenCalledWith('u1', 30);
});
});嚴格依照 Fowler 的語意,這種「先設定 mockResolvedValue,再 toHaveBeenCalledWith」的用法,就是典型 Mock:同時驗證狀態與互動
Vitest 的 mock function vi,fn() 可以同時扮演 Stub + Spy + Mock,差別在你只設定回傳值、讀 mock.calls 斷言,還是再搭配 toHaveBeenCalledWith 等行為驗證 API。
Fake 不是 Dummy, Stub, Spy 或 Mock,它是一種模擬器 simulator,是「真正可工作的簡化實作」,重點在於資料結構和邏輯是存在的,只是比正式實作簡單/走捷徑,例如 in‑memory DB、in‑memory mail sender。
Uncle Bob 很少用 Fake,因為 Fake 會隨著系統複雜度提高、測試條件變多而變大變複雜
// real-user-repo.js
export class RealUserRepository {
constructor(db) {
this.db = db;
}
async findByUsername(username) {
// 真實情況:打 DB 或 API
return this.db.query('SELECT * FROM users WHERE username = ?', [username]);
}
}
// in-memory-user-repo.fake.js
export class InMemoryUserRepository {
// Fake:可運作、但只存在記憶體
constructor(initialUsers = []) {
this.users = new Map(initialUsers.map((u) => [u.username, u]));
}
async findByUsername(username) {
return this.users.get(username) ?? null;
}
async save(user) {
this.users.set(user.username, user);
}
}
// 使用 Fake 的測試
import { describe, it, expect } from 'vitest';
import { createAuthService } from './auth-service.js';
import { InMemoryUserRepository } from './in-memory-user-repo.fake.js';
describe('AuthService with Fake repository', () => {
it('logs in with a real-like repo in memory', async () => {
const userRepoFake = new InMemoryUserRepository([
{ username: 'alice', password: 'secret' },
]); // Fake
const auth = createAuthService(userRepoFake);
const result = await auth.login('alice', 'secret');
expect(result).toBe(true);
});
});
InMemoryUserRepository 有「狀態」與「邏輯」,甚至有 save,已經相當接近真實實作,但因為沒有交易、沒有真正的 I/O,所以是典型 Fake。
可能無法完全複製真實依賴關係,無意間掩蓋了真實情境不明顯的副作用 bug,或引入額外的、真實情境不存在的 bug
確定性越高,測試就越沒有彈性
測試彈性越高,確定性越低
Spy 測試很脆弱,因為測試本身和實作行為高度耦合,演算法變更就會讓測試需要修正或重寫,Mock 亦同
這是 Uncle Bob 不喜歡模擬工具 mocking tool 的原因
Uncle Bob 個人喜歡彈性高一點,所以會選用值測試(配對輸入和輸出值)和屬性測試(使用一群輸入值確認條件不變)
也就是 Fowler 文章裡的「狀態驗證」
狀態驗證測試對實作細節相對不敏感;行為驗證測試表達力強但脆弱度高
這份取捨直接影響程式架構的設計過程,也就是解決問題的思考流程、實作使用者介面和商業邏輯的推導過程不同
今日讀物:《Clean Craftsmanship 無暇的程式碼 軟體工匠篇》(Chapter 3 進階 TDD)、《Test-Driven Development 學習手冊》(迅速翻完,沒有實作)
2000 年 Gerard Meszaros 的著作《xUnit Test Patterns: Refractoring Test Code》定義了非正式模擬物件 (Mock Object) 的五種類型,統稱這些物件為「測試替身 Test Doubles」:執行測試時,測試替身代替了另一個物件
Dummy 虛擬物件
Stub 虛擬常式或擬態物件
Spy 情蒐或間諜物件
Mock 模擬物件
Fake 假物件
Test Double
|
+-----------------------+------------------------+
| |
行為鏈:能力遞增 簡化真實實作
| |
只佔位 ──► Dummy |
(不驗證,不被使用) |
| |
給假資料 ──► Stub |
(控制間接輸入,做 state verification) |
| |
記錄互動 ──► Spy |
(記錄間接輸出,事後 assert 行為) |
| |
檢查互動是否符合預期 ──► Mock Fake Object
(事先定義期望,行為驗證 behavior verification) (有邏輯的簡化實作)被測函式需要輸入物件但不需要對物件做任何邏輯處理的時候,Dummy 此時實作介面 Implement interface 但什麼都不做,以避免在測試程式中宣告一個複雜的物件,通常不會真的被 SUT (System Under Test) 使用到,就是在輸入參數的地方佔位;若要回傳通常會是 null 或 0,多數情況不會被讀取
例:登入驗證函式必須在接收使用者名稱和密碼輸入值後才啟動,Dummy 就可以直接設定成一組空的使用者名稱和密碼 Uncle Bob 個人不是很喜歡,因為會有路徑引用和依賴鏈問題
// auth-service.js
export const login = (username, password, logger) => {
// logger 會被呼叫,但這個測試用不到
if (!username || !password) {
logger.error('missing credentials');
return false;
}
return true;
};
// auth-service.test.js
import { describe, it, expect } from 'vitest';
import { login } from './auth-service.js';
describe('login', () => {
it('returns false when username is empty', () => {
const dummyLogger = {
// 實作介面,但什麼都不做
error: () => {},
}; // Dummy
const result = login('', 'password', dummyLogger);
expect(result).toBe(false);
});
});dummyLogger 實作了需要的介面 (error),但測試完全不會對它做任何驗證,就是純佔位。
Stub 是一種 Dummy,但可以回傳一些驅動被測函式繼續執行邏輯流程的值 test-specific value (或稱測試專用值),用 SUT 的狀態或回傳值驗證 回傳的是「預先寫死」的值,不太關心呼叫了幾次、順序如何
例:回傳 true/ false,確認登入驗證函式接收合法/非法使用者名稱和密碼輸入值後是否按照設計流程執行
// user-repo.js
export class UserRepository {
findByUsername(username) {
throw new Error('not implemented');
}
}
// auth-service.js
export const createAuthService = (userRepository) => ({
async login(username, password) {
const user = await userRepository.findByUsername(username);
if (!user) return false;
return user.password === password;
},
});
// auth-service.stub.test.js
import { describe, it, expect } from 'vitest';
import { createAuthService } from './auth-service.js';
describe('AuthService with Stub', () => {
it('returns true when username and password match', async () => {
const userRepoStub = {
// 只回傳測試專用值
async findByUsername(username) {
if (username === 'alice') {
return { username: 'alice', password: 'secret' };
}
return null;
},
}; // Stub
const auth = createAuthService(userRepoStub);
const result = await auth.login('alice', 'secret');
expect(result).toBe(true);
});
});userRepoStub 不記錄誰呼叫它、也沒有期望被呼叫幾次,只單純「給假資料」,這就是 Stub。
Spy 是一種 Stub,回傳測試專用值使被測函式通過期望路徑,同時會「紀錄被怎麼呼叫」(包含呼叫的歷史、呼叫時使用的參數),之後由測試來檢查紀錄與斷言
// email-service.js
export const notifyLogin = async (emailClient, user) => {
await emailClient.send({
to: user.email,
subject: 'Login detected',
});
};
// email-service.spy.test.js
import { describe, it, expect } from 'vitest';
import { notifyLogin } from './email-service.js';
describe('notifyLogin with Spy', () => {
it('sends an email to user email', async () => {
const calls = [];
const emailClientSpy = {
async send(message) {
calls.push(message);
// 也可以在這裡回傳測試專用值
return { ok: true };
},
}; // Spy:同時是 Stub + 記錄呼叫
const user = { email: 'user@example.com' };
await notifyLogin(emailClientSpy, user);
// 驗證「怎麼被呼叫」在測試裡寫
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({
to: 'user@example.com',
subject: 'Login detected',
});
});
});emailClientSpy 自己不會「判斷測試成敗」,只是把資訊記下來,由測試 expect 來驗證,這就是 Spy 的典型用法。
如果使用 Vitest 的 vi.fn() ,不對它做任何期望檢查,只讀 mock.calls 來 assert,其角色偏向 Spy;一旦搭配 toHaveBeenCalledWith 等 API,就是 Mock。
Mock 是一種 Spy,回傳測試專用值使被測函式通過期望路徑,同時會記錄被怎麽呼叫,根據事先設定的預期結果判斷測試成功或失敗,屬於行為驗證(behavior verification)
Spy 和 Mock 都用來驗證間接輸出,只是一個是「把記錄交給測試自行斷言」,一個是「把期望預先設定好,由框架檢查」。
Uncle Bob 個人不太喜歡,因為 Mock 讓 Spy 的行為和測試驗證整個流程緊密耦合,而不是他個人偏好的直接陳述驗證內容
// payment-service.js
export const chargeOrder = async (paymentGateway, order) => {
const amount = order.items.reduce((sum, item) => sum + item.price, 0);
return paymentGateway.charge(order.userId, amount);
};
// payment-service.mock.test.js
import { describe, it, expect, vi } from 'vitest';
import { chargeOrder } from './payment-service.js';
describe('chargeOrder with Mock', () => {
it('charges total amount to user', async () => {
const paymentGatewayMock = {
charge: vi.fn().mockResolvedValue({ success: true }),
}; // Mock function:有預期回傳、也會被驗證行為
const order = {
userId: 'u1',
items: [
{ price: 10 },
{ price: 20 },
],
};
const result = await chargeOrder(paymentGatewayMock, order);
expect(result).toEqual({ success: true });
// 行為驗證:這裡才是「Mock 的重點」
expect(paymentGatewayMock.charge).toHaveBeenCalledTimes(1);
expect(paymentGatewayMock.charge).toHaveBeenCalledWith('u1', 30);
});
});嚴格依照 Fowler 的語意,這種「先設定 mockResolvedValue,再 toHaveBeenCalledWith」的用法,就是典型 Mock:同時驗證狀態與互動
Vitest 的 mock function vi,fn() 可以同時扮演 Stub + Spy + Mock,差別在你只設定回傳值、讀 mock.calls 斷言,還是再搭配 toHaveBeenCalledWith 等行為驗證 API。
Fake 不是 Dummy, Stub, Spy 或 Mock,它是一種模擬器 simulator,是「真正可工作的簡化實作」,重點在於資料結構和邏輯是存在的,只是比正式實作簡單/走捷徑,例如 in‑memory DB、in‑memory mail sender。
Uncle Bob 很少用 Fake,因為 Fake 會隨著系統複雜度提高、測試條件變多而變大變複雜
// real-user-repo.js
export class RealUserRepository {
constructor(db) {
this.db = db;
}
async findByUsername(username) {
// 真實情況:打 DB 或 API
return this.db.query('SELECT * FROM users WHERE username = ?', [username]);
}
}
// in-memory-user-repo.fake.js
export class InMemoryUserRepository {
// Fake:可運作、但只存在記憶體
constructor(initialUsers = []) {
this.users = new Map(initialUsers.map((u) => [u.username, u]));
}
async findByUsername(username) {
return this.users.get(username) ?? null;
}
async save(user) {
this.users.set(user.username, user);
}
}
// 使用 Fake 的測試
import { describe, it, expect } from 'vitest';
import { createAuthService } from './auth-service.js';
import { InMemoryUserRepository } from './in-memory-user-repo.fake.js';
describe('AuthService with Fake repository', () => {
it('logs in with a real-like repo in memory', async () => {
const userRepoFake = new InMemoryUserRepository([
{ username: 'alice', password: 'secret' },
]); // Fake
const auth = createAuthService(userRepoFake);
const result = await auth.login('alice', 'secret');
expect(result).toBe(true);
});
});
InMemoryUserRepository 有「狀態」與「邏輯」,甚至有 save,已經相當接近真實實作,但因為沒有交易、沒有真正的 I/O,所以是典型 Fake。
可能無法完全複製真實依賴關係,無意間掩蓋了真實情境不明顯的副作用 bug,或引入額外的、真實情境不存在的 bug
確定性越高,測試就越沒有彈性
測試彈性越高,確定性越低
Spy 測試很脆弱,因為測試本身和實作行為高度耦合,演算法變更就會讓測試需要修正或重寫,Mock 亦同
這是 Uncle Bob 不喜歡模擬工具 mocking tool 的原因
Uncle Bob 個人喜歡彈性高一點,所以會選用值測試(配對輸入和輸出值)和屬性測試(使用一群輸入值確認條件不變)
也就是 Fowler 文章裡的「狀態驗證」
狀態驗證測試對實作細節相對不敏感;行為驗證測試表達力強但脆弱度高
這份取捨直接影響程式架構的設計過程,也就是解決問題的思考流程、實作使用者介面和商業邏輯的推導過程不同
線上課程觀課進度管理小工具開發日誌
Day 6:JavaScript 程式碼執行排序:遞迴函數、Call Stack、Task Queue
Call Stack;遞迴函數與 call stack 的關係;Task Queue(非同步的概念再釐清)
Day 9:「修煉圈圈 Practice Rings」Web app 開發日誌 - 2025 六角 AI Vibe Coding 體驗營期末大魔王作業
這份筆記旨在記錄 2025 六角 Vibe Coding 體驗營每日作業 Day 21 的期末專案成果,從一個簡單的個人痛點出發,透過與 AI(在我這個情境中是 Perplexity)協作,在一天內完成了一個包含前後端的全端網頁小工具:「修煉圈圈 Practice Rings」。
線上課程觀課進度管理小工具開發日誌
Day 6:JavaScript 程式碼執行排序:遞迴函數、Call Stack、Task Queue
Call Stack;遞迴函數與 call stack 的關係;Task Queue(非同步的概念再釐清)
Day 9:「修煉圈圈 Practice Rings」Web app 開發日誌 - 2025 六角 AI Vibe Coding 體驗營期末大魔王作業
這份筆記旨在記錄 2025 六角 Vibe Coding 體驗營每日作業 Day 21 的期末專案成果,從一個簡單的個人痛點出發,透過與 AI(在我這個情境中是 Perplexity)協作,在一天內完成了一個包含前後端的全端網頁小工具:「修煉圈圈 Practice Rings」。
<100 subscribers
<100 subscribers
Share Dialog
Share Dialog
No comments yet